追踪一个 Zsh 历史记录数据丢失漏洞 🐞
文章背景与核心概要
多年来,作者一直饱受一个神秘数据丢失问题的困扰:Zsh 历史记录文件 (~/.zsh_history) 中较新的条目会凭空消失,仅残留极旧的历史数据。通过结合文件监控工具(inotify、fatrace)、高性能 eBPF 追踪工具(bpftrace)、通过自定义源码补丁强制触发崩溃,以及利用 GDB 调试核心转储(core dumps),作者最终揭开了根本原因:Zsh 的历史记录文件压缩函数(savehistfile)在执行时未能检查用户中断信号(SIGINT)。当用户在连续快速按下 Ctrl+C 和 Ctrl+D 的同时退出 Shell 时,readhistfile 会由于中断标志而提前短路返回,而随后的 savehistfile 却会盲目地将这些不完整、被截断的数据重写覆盖到历史文件中。
本文生动记录了作者从表象到工具排查、从源码修补到根因剖析的全过程,并探讨了利用 AI 智能体(Agent)排查该漏洞的效果,为开发者提供了一篇极具参考价值的底层调试实战指南。
目录
- 好消息在前
- 故障现象
- 我的 Zsh 历史记录配置
- 追踪行为
- inotify
- fatrace
- strace
- bpftrace
- 让它崩溃!
- 究竟是什么 Bug?
- 结论
- 附录 A:隐藏的坑:导出的 HISTFILE
- 附录 B:附加问题:AI 能找到这个 Bug 吗?
好消息在前
Zsh 5.9.2(发布于 2026 年 7 月 12 日)包含针对此问题的修复 —— 建议在读完这篇调查报告后再去查看,以免剧透!
剧透:上游修复链接 Zsh 修复 53454
故障现象
偶尔,我会发现前一天明明执行过的命令在 Shell 历史记录中找不到了,这意味着按下 Ctrl+R 进行向后历史搜索时没有任何结果。每当我注意到这一点时,我的 Shell 历史文件里只剩下非常旧的条目,近几年的新条目全部不翼而飞。
前几次遇到这种情况时,我只是从日常备份中恢复了 Shell 历史记录,并没有深究。但这个问题却反复出现。
我注意到 .zsh_history 文件表面上并没有损坏(没有不可打印字符或不完整的文本行),并且文件中的行数也并非每次都固定。
当时我不清楚是 Zsh 本身、其他程序,还是多个 zsh(1) 进程组合使用时引发了这个问题。
我的 Zsh 历史配置
我在 ~/.zshrc 中设置了以下与历史记录相关的选项:
# 加载 4000 行历史记录(用于 Ctrl+R 向后搜索),但保存 O(∞) 条
HISTSIZE=4000
HISTFILE=~/.zsh_history
SAVEHIST=10000000
# 不保存(相邻的)重复条目
setopt HIST_IGNORE_DUPS
# 当命令运行时,将历史条目追加到 `~/.zsh_history` 中。
setopt INC_APPEND_HISTORY
# ……但不同步历史记录(NixOS 的 /etc/zshrc 中默认启用)。
unsetopt SHARE_HISTORY
在实际应用中,这意味着我的各个 Shell 是独立的会话,它们将各自的命令流式写入共享的 ~/.zsh_history 中。历史记录故意不进行自动共享,因此当我想要访问其他 Shell 写入的条目时,我会显式运行 exec zsh。
追踪行为
当我于 2024 年 12 月在 Mastodon 上求助时(主要希望有人已经遇到并诊断过这个问题),收到的建议之一是使用文件系统更改监控机制(如 inotify 或 fsevents)来找出截断(或更改?)Zsh 历史记录文件的罪魁祸首。
接下来的几个小节将介绍我在 Linux 上尝试过的方法。
inotify
Linux 内核子系统 inotify(7) 是 Linux 中最古老的文件系统更改监控 API 之一(发布于 2005 年)。要彻底搞清楚 Zsh 是如何修改历史文件的,仅仅监控 .zsh_history 本身是不够的:
midna ~ % inotifywait --monitor .zsh_history
Setting up watches.
Watches established.
.zsh_history OPEN
.zsh_history ACCESS
.zsh_history ACCESS
[…]
.zsh_history ACCESS
.zsh_history CLOSE_NOWRITE,CLOSE
.zsh_history ATTRIB
.zsh_history CLOSE_WRITE,CLOSE
.zsh_history DELETE_SELF
^C
文件被打开、访问(= 读取),然后……被删除了?!
通过监控其所在的目录,我们可以看到完整的全貌:
midna ~ % inotifywait --monitor ~
/home/michael/ OPEN .zsh_history
/home/michael/ ACCESS .zsh_history
/home/michael/ ACCESS .zsh_history
[…]
/home/michael/ ACCESS .zsh_history
/home/michael/ CLOSE_NOWRITE,CLOSE .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
/home/michael/ OPEN .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
/home/michael/ OPEN .zsh_history
/home/michael/ ACCESS .zsh_history
/home/michael/ CLOSE_NOWRITE,CLOSE .zsh_history
/home/michael/ CREATE .zsh_history.new
/home/michael/ OPEN .zsh_history.new
/home/michael/ ATTRIB .zsh_history.new
/home/michael/ MODIFY .zsh_history.new
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history.new
/home/michael/ MOVED_FROM .zsh_history.new
/home/michael/ MOVED_TO .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
原来,Zsh 先读取旧历史文件的内容,将其写入一个新文件,然后将新文件重命名(覆盖)到旧文件上,从而间接删除了旧文件。这样一来就解释得通了!
不幸的是,我们无法从事件中看到引发该文件系统事件的进程 ID (PID),甚至连使用其兄弟工具 fsnotifywait(1) 也不行。该工具使用的是 fanotify(7),这是一个确实提供此信息的 API!我检查过,内核确实发送了 PID,但 fsnotifywait 没有将其显示出来。
fatrace
幸运的是,我们有 fatrace(8),它能够显示进程名和 PID。
以下是使用 fatrace(8) 观察到的 Zsh 历史记录重写过程:
zsh(197994): CWO /home/michael/.zsh_history
zsh(197994): O /home/michael/.zsh_history
zsh(197994): R /home/michael/.zsh_history
zsh(197994): R /home/michael/.zsh_history
[…]
zsh(197994): R /home/michael/.zsh_history
zsh(197994): C /home/michael/.zsh_history
zsh(197994): + /home/michael
zsh(197994): O /home/michael/.zsh_history.new
zsh(197994): W /home/michael/.zsh_history.new
zsh(197994): W /home/michael/.zsh_history.new
zsh(197994): W /home/michael/.zsh_history.new
[…]
zsh(197994): W /home/michael/.zsh_history.new
zsh(197994): CW /home/michael/.zsh_history.new
zsh(197994): <> /home/michael
zsh(197994): CW (deleted)
zsh(197994): C /nix/store/80vwnjjgcrbp41pk927r8lzybjhy0k73-zsh-5.9.1/bin/zsh
[…]
这给出了 PID,因此我们现在可以验证是否有多进程参与了 shell 历史记录的损坏。但是,我们依然无法得知每个 Zsh PID 究竟读取/写入了多少数据,所以单靠 fatrace 日志,仍然无法搞清楚到底发生了什么。
strace
当然,你也可以使用 strace(1),特别是配合 -k 标志来进一步查看 Zsh 的行为。但要想让每一个(交互式)Zsh 进程都附带运行对应的 strace 简直是一场后勤噩梦,而且我不确定在 Shell 上持续运行 strace 是否会以微妙的方式改变其行为,因此我没有采用 strace 路线。
(一旦我做出了可复现的测试用例,strace 用起来就变得很简单且非常有用。)
bpftrace
为了更深入地观察 Zsh 的读写操作,我们可以求助于 bpftrace(8)。
首先,我编写了以下 bpftrace 程序,它会在每次调用 open(2) 系统调用时触发,并记录是哪个进程打开了 .zsh_history 文件,同时打印出用户态的堆栈跟踪:
tracepoint:syscalls:sys_enter_open,
tracepoint:syscalls:sys_enter_openat,
tracepoint:syscalls:sys_enter_openat2
/str(args.filename) == "/home/michael/.zsh_history" || str(args.filename) == ".zsh_history"/
{
printf("%-6d %-16s open(%s)%s", pid, comm, str(args.filename), ustack);
}
在 NixOS 26.05 上,我可以按如下方式运行该程序:
midna ~ % nix shell nixpkgs#bpftrace
midna ~ 2 % sudo bpftrace path.bt
Attached 3 probes
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
lockhistfile+642
readhistfile+2213
zsh_main+1118
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
_IO_file_open+51
_IO_file_fopen@@GLIBC_2.2.5+303
__fopen_internal+134
readhistfile+2277
zsh_main+1118
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
lockhistfile+642
savehistfile+165
zexit+204
zsh_main+1522
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
savehistfile+752
zexit+204
zsh_main+1522
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
_IO_file_open+51
_IO_file_fopen@@GLIBC_2.2.5+303
__fopen_internal+134
readhistfile+2277
savehistfile+2498
zexit+204
zsh_main+1522
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
^C
在这项早期成功的鼓舞下,我进一步扩展了程序以覆盖更多的系统调用:
完整的
zshhisttrace.btbpftrace 代码#!/usr/bin/bpftrace #include <fcntl.h> #include <limits.h> tracepoint:syscalls:sys_enter_open /comm == "zsh"/ { printf("%s(%d) open: %s flags %x mode %x\n", comm, pid, str(args->filename), args->flags, args->mode); } tracepoint:syscalls:sys_enter_openat { if (!strcontains(str(args->filename), "zsh_history")) { delete(@openfn[tid]); return; } @openfn[tid] = 1; printf("%s(%d) openat: ", comm, pid); if (args->dfd < 0x7fffffff) { /* 应该写为 != AT_FDCWD,但那样写行不通 !?!? */ printf("[at fd %d]", args->dfd); } printf("%s flags %x mode %x\n", str(args->filename), args->flags, args->mode); } tracepoint:syscalls:sys_exit_openat /@openfn[tid]/ { @reads[tid,(int64)args->ret] = 1; // TODO: bpftrace 0.22 引入了 has_key @writes[tid,(int64)args->ret] = 1; // TODO: bpftrace 0.22 引入了 has_key } tracepoint:syscalls:sys_enter_close /@reads[tid,(int64)args->fd]/ { printf("%s(%d) close %d (reads: %d, writes: %d)\n", comm, pid, args->fd, @reads[tid,(int64)args->fd]-1, @writes[tid,(int64)args->fd]-1); delete(@reads[tid,(int64)args->fd]); delete(@writes[tid,(int64)args->fd]); } tracepoint:syscalls:sys_enter_rename /comm == "zsh"/ { printf("%s(%d) rename:", comm, pid); printf("%s -> %s\n", str(args->oldname), str(args->newname)); } tracepoint:syscalls:sys_enter_symlink /comm == "zsh"/ { printf("%s(%d) symlink ", comm, pid); printf("%s -> %s\n", str(args->oldname), str(args->newname)); } tracepoint:syscalls:sys_enter_unlink /comm == "zsh"/ { printf("%s(%d) unlink ", comm, pid); printf("%s\n", str(args->pathname)); } tracepoint:syscalls:sys_enter_unlinkat /comm == "zsh"/ { printf("%s(%d) unlinkat ", comm, pid); printf("%s\n", str(args->pathname)); } tracepoint:syscalls:sys_enter_lseek /comm == "zsh"/ { printf("%s(%d) lseek fd %d offset %d whence %d\n", comm, pid, args->fd, args->offset, args->whence); } tracepoint:syscalls:sys_enter_read /@reads[tid,(int64)args->fd]/ { @reads[tid,(int64)args->fd] += args->count; } tracepoint:syscalls:sys_exit_read /comm == "zsh"/ { if (args->ret <= 0) { printf("%s(%d) read = %d\n", comm, pid, args->ret); } } tracepoint:syscalls:sys_exit_write /comm == "zsh"/ { if (args->ret <= 0) { printf("%s(%d) write = %d\n", comm, pid, args->ret); } } tracepoint:syscalls:sys_enter_write /@writes[tid,(int64)args->fd]/ { @writes[tid,(int64)args->fd] += args->count; }
如果你想更深入地了解 bpftrace,这里有一些我觉得非常有用的参考资料: * 上游 bpftrace 文档 * Martin Pitt 的博客文章 “First steps in system-wide Linux tracing” (2020) * Brendan Gregg 在 LSFMM 上的演讲 “BPF Observability” (2019)
我创建了一个 systemd 单元文件,让这个程序在后台永久运行(开销似乎很低),这意味着我可以像这样检查日志:
midna % journalctl -fu zshhisttrace
cp(2338700) close 3 (reads: 3407872, writes: 0)
zsh(231222) symlink /pid-231222/host-midna -> /home/michael/.zsh_history.LOCK
zsh(231222) openat: /home/michael/.zsh_history flags 541 mode 180
zsh(231222) close 3 (reads: 0, writes: 0)
zsh(231222) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231222) lseek fd 3 offset 0 whence 1
zsh(231222) read = 0
zsh(231222) close 3 (reads: 52895744, writes: 0)
zsh(231222) unlink /home/michael/.zsh_history.new
zsh(231222) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231222) close 3 (reads: 0, writes: 52888907)
zsh(231222) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
zsh(231222) unlink /home/michael/.zsh_history.LOCK
有一天,我发现我的 shell 历史记录被截断了,于是去检查日志。这就是我发现的情况。请注意,其中没有 read = 0 这一行,也就是说 Zsh 并没有读到文件末尾 (EOF):
zsh(231233) symlink /pid-231233/host-midna -> /home/michael/.zsh_history.LOCK
zsh(231233) openat: /home/michael/.zsh_history flags 541 mode 180
zsh(231233) close 3 (reads: 0, writes: 0)
zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231233) lseek fd 3 offset 0 whence 1
zsh(231233) lseek fd 3 offset 0 whence 1
zsh(231233) lseek fd 3 offset 11572944 whence 0
zsh(231233) close 3 (reads: 11575296, writes: 0)
zsh(231233) unlink /home/michael/.zsh_history.new
zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231233) close 3 (reads: 0, writes: 11572944)
zsh(231233) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
zsh(231233) unlink /home/michael/.zsh_history.LOCK
让它崩溃!
从上面的 bpftrace 输出中,我们知道 Zsh 错误地重写了我的 .zsh_history 文件:它读取的行数比平时少,然后正确地将它们写入了 .zsh_history.new。
此时我决定研究代码,寻找为什么 readhistfile 没有读取完整的历史文件,或者为什么 savehistfile 没有写入完整历史文件的原因。
savehistfile 的控制流相当难以理清,但修改代码(zsh-5.9.1)使其在写入行数少于 50000 行的 .zsh_history.new 之后、且在用这个被截断的新文件替换我的 .zsh_history 之前发生崩溃,是很容易做到的:
--- i/Src/hist.c
+++ w/Src/hist.c
@@ -2994,6 +2994,7 @@ savehistfile(char *fn, int err, int writeflags)
if (out) {
char *history_ignore;
Patprog histpat = NULL;
+ int lines_written = 0;
pushheap();
@@ -3048,6 +3049,7 @@ savehistfile(char *fn, int err, int writeflags)
ret = fputc(' ', out);
if (ret < 0 || (ret = fputc('\n', out)) < 0)
break;
+ lines_written++;
}
if (ret >= 0 && start && writeflags & HFILE_USE_OPTIONS) {
struct stat sb;
@@ -3062,6 +3064,10 @@ savehistfile(char *fn, int err, int writeflags)
}
if (fclose(out) < 0 && ret >= 0)
ret = -1;
+ if (tmpfile && lines_written < 50000) {
+ char *crashptr = (char*)0x23;
+ *crashptr = 42;
+ }
if (ret >= 0) {
if (tmpfile) {
if (rename(tmpfile, unmeta(fn)) < 0) {
在 Linux 上,确保此类崩溃最终能够落入某个有用地方的最简单方法是安装 systemd-coredump(8),安装后 systemd 会自动收集核心转储。你可以使用 coredumpctl(1) 来列出并处理它们。请注意,这些核心转储包含你的 shell 历史记录,因此切勿将其上传到第三方服务。Fedora 的 ABRT 似乎只发送微型报告(即不包含你的完整 shell 历史记录),而 Ubuntu 的 Apport 默认是禁用的,不过最好还是仔细检查一下。
我安装了修补后的 Zsh 版本(启用了调试符号),并推迟了进一步的调查,直到抓到一个正在发生该问题的核心转储。果不其然,几天后当我用 coredumpctl 检查时,我看到了一个崩溃!这就是回溯信息:
midna % coredumpctl debug
gdb $ bt full
#0 0x000056040d781e19 in savehistfile (fn=0x56040f7a76b0 "/home/michael/.zsh_history", err=1, writeflags=0) at hist.c:3086
crashptr = 0x23 <error: Cannot access memory at address 0x23>
history_ignore = 0x0
histpat = 0x0
lines_written = 45546
t = 0x5604102a1f59 ""
tmpfile = 0x5604100ec210 "/home/michael/.zsh_history.new"
start = 0x5604102a1f40 "make -j32"
out = 0x56040f939400
he = 0x0
xcurhist = 45546
extended_history = 0
ret = 10
#1 0x000056040d781f72 in savehistfile (fn=0x56040f7a76b0 "/home/michael/.zsh_history", err=1, writeflags=32771) at hist.c:3121
remember_histactive = 0
history_ignore = 0x0
histpat = 0x0
lines_written = 0
t = 0x0
tmpfile = 0x0
start = 0x0
out = 0x56040f939400
he = 0x0
xcurhist = 51183
extended_history = 0
ret = 0
#2 0x000056040d751197 in zexit (val=0, from_where=ZEXIT_NORMAL) at builtin.c:6055
writeflags = 32768
#3 0x000056040d7888e2 in zsh_main (argc=2, argv=0x7ffd370c1758) at init.c:1950
errexit = 0
t = 0x7ffd370c1768
runscript = 0x0
zsh_name = 0x7ffd370c26bd "zsh"
cmd = 0x0
t0 = 162
#4 0x000056040d735d89 in main (argc=2, argv=0x7ffd370c1758) at ./main.c:93
No locals.
我重新审视了源码,意识到极有可能是 savehistfile 写出了一个较短的历史文件,因为 readhistfile 留给它的就是一个较短的历史!
readhistfile 的控制流更容易理解。通读该函数,发现有一种提早返回的可能:当 Zsh 接收到信号时,读取循环会通过 break; 中断:
// …
if (errflag & ERRFLAG_INT) {
/* 如果被中断,下次不能假设可以快速读取。 */
lasthist.interrupted = 1;
break;
}
// …
让我们看看在我们的崩溃中 errflag 和 lasthist.interrupted 包含什么:
gdb $ p errflag
$1 = 2
gdb $ p lasthist.interrupted
$2 = 1
太棒了!所以必然涉及某种信号。
由于本文范围之外的原因,我正在使用一个 mosh 会话,从该会话中启动一个长时间运行的 SSH 会话,并通过它多路复用进一步的会话。在每个工作日结束时拆除此设置时,我在多路复用会话中按下 Ctrl+D(发送 EOF,退出会话),然后在长时间运行的 SSH 上按下 Ctrl+C,接着按下 Ctrl+D 退出 mosh 会话。
(如果你没有干净地退出 mosh 会话,它会留在服务器上,后续登录会提醒你这些孤儿会话。我希望避免累积孤儿会话。)
所以在实际操作中,我会不断按下 Ctrl+D、Ctrl+C、Ctrl+D、Ctrl+C 等,直到所有窗口都消失。作为该序列的一部分,极有可能是我在退出一个 Zsh 会话(Ctrl+D)时,由于历史重写耗时足够长,随即又对其 readhistfile 进行了中断(Ctrl+C)。
有了这些线索,我编写了一个独立的复现用例,并于 2025 年 3 月向 zsh-workers 邮件列表发送了 Bug 报告。Bart Schaefer 对此进行了调查,并于 2025 年 4 月发布了修复补丁(非常感谢!)。
该修复花了很长时间才真正发布,因为中间有很长一段时间没有发布任何 Zsh 版本。然后,在发布 5.9.1 版本时,发布工程师居然漏掉了 Bart 的修复!我指出了这个疏忽,所幸 Zsh 5.9.2 包含了这个修复。
我一直在运行应用了 Bart 补丁的 Zsh 5.9,并且会一直将该版本锁定(pin),直到 5.9.2 登陆我的电脑。如果你在 Debian 上锁定 zsh,请同时锁定 zsh 和 zsh-common 软件包。否则,你可能会在某一天发现系统里连 zsh 软件包都没有了……
究竟是什么 Bug?
在退出时,zexit 会调用 savehistfile 来压缩历史记录:在会话期间,历史条目是增量追加的,但在 Shell 退出时,历史记录文件会被压缩(例如,应用大小限制),因此 savehistfile 会读取整个历史记录(readhistfile)并将其重新写出。
当信号触发时,readhistfile 可能会被中断(它会检查 errflag & ERRFLAG_INT 并提前终止其读取循环),但 savehistfile 在退出时写入 shell 历史记录时却没有检查中断。因此,savehistfile 写入了(不完整的)历史记录,从而截断了实际的历史。
让我们解密一下我们之前收集的 bpftrace 输出:
zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231233) lseek fd 3 offset 0 whence 1
# […] 读取被聚合,见下文 […]
# […] 中断发生在此处 […]
# lseek(3, 0, SEEK_CUR) = 查询当前的寻址偏移量
zsh(231233) lseek fd 3 offset 0 whence 1
# 根据 POSIX 规定,在 fclose() 时执行 SEEK_SET(见下文)
zsh(231233) lseek fd 3 offset 11572944 whence 0
zsh(231233) close 3 (reads: 11575296, writes: 0)
zsh(231233) unlink /home/michael/.zsh_history.new
zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231233) close 3 (reads: 0, writes: 11572944)
zsh(231233) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
为什么要进行 lseek?根据 POSIX.1-2017 关于 fclose() 的说明:
如果文件尚未到达 EOF,并且该文件支持寻址,则如果流是对底层打开文件描述符的活动句柄,底层打开文件描述符的文件偏移量应被设置为该流的文件位置。
Zsh 使用 fopen() 来获取一个流,因此 glibc 会以 4096 字节的块进行读取。当关闭流时,底层的 文件描述符需要被回溯寻址(seek back),以便下一个流能够正确地重新读取当前 4096 字节块中已经被读取过的那部分内容。(Zsh 随后立即关闭了文件,因此这个 seek 是毫无意义的,但 glibc 无从知晓。)
结论
令人惊叹的是,像这样会导致数据丢失的 Bug,竟然可以在一个流行的 Shell 中潜伏 10 年而未被修复(你知道吗?苹果在 2019 年将 macOS 的默认登录 Shell 切换为了 Zsh)。
当然,大多数用户可能没有我这种习惯:以极易发送 SIGINT 的方式强行终止 Shell 会话,但我敢想象肯定有某些用户也曾经丢失过部分历史记录。
我非常高兴这个问题现在终于修复了!如果你也遇到了历史记录文件被截断的问题,并且不是本文描述的这种情形,也许你是不小心导出了 HISTFILE?请参阅附录 A,了解我在几年前遇到过的一个关于 HISTFILE 的额外“大坑”。
我在写这篇文章时想到的另一个显而易见的问题是:我在 LLM 变得在编程和解决问题方面表现惊人之前就追踪到了这个问题。今天的 AI 编程智能体能够找到这个 Bug 吗?详情请参见附录 B,但答案是:今天的尖端模型完全可以找到这个 Bug!
附录 A:隐藏的坑:导出的 HISTFILE
当你使用 Emacs 的 TRAMP 模式 时,它默认会导出 HISTFILE。例如,在启动 emacs /ssh:keep:/srv/keep 后使用 M-x shell,我会在环境中看到 HISTFILE:
/ssh:keep:/srv/keep/ #$ env | grep HISTFILE
HISTFILE=/home/michael/.tramp_history
/ssh:keep:/srv/keep/ #$
这是一个巨坑,因为大多数 Shell 配置不会取消导出(unexport)HISTFILE,而只是更改它。例如,在我的 ~/.zshrc 中,我设置了 HISTFILE=~/.zsh_history。
当运行交互式 Shell(通过键入 zsh 后跟回车)时,我的环境变量中仍然留存着 HISTFILE:
/ssh:keep:/srv/keep/ #$ zsh
locale: Cannot set LC_CTYPE to default locale: No such file or directory
$ env | grep HISTFILE
HISTFILE=/home/michael/.zsh_history
$
……而当我使用 ssh(1) 登录时则不会这样:
midna ~ % ssh keep
Last login: Sat Aug 1 17:38:37 2026 from 100.64.1.1
keep ~ % env | grep HISTFILE
keep ~ %
在那些将其他 Shell 配置为具有其他(默认)设置的机器上,导出 Shell 特定的 HISTFILE 是一个容易踩坑的陷阱。在我的工作电脑上,由于 Linux 安装默认将 bash 的 HISTSIZE=64000 和 HISTFILESIZE=64000,我曾经无意间将我的 ~/.zsh_history 文件截断到了 64000 行。我怀疑当时的操作是:运行 M-x shell,然后输入 zsh(以加载我的配置),接着输入 bash(临时运行以加载某个配置并启动脚本)。
为了防止将来再次出现此类问题,我决定在我的 ~/.zshrc 中主动取消导出 HISTFILE。
附录 B:附加问题:AI 能找到这个 Bug 吗?
一段时间以来,我一直觉得亲自动手创建自己的评估集(evals)会很有用。如果你对“eval”这个词不熟悉,请参阅 Anthropic 的“Demystifying evals for AI agents”。
我最初使用的是 Simon Willison 的 smevals,但发现它过于简约:如果不采取额外的防范措施,AI 智能体很容易跳出评测任务去偷看解决方案,或者使用互联网发现 Zsh git 版本中已经修复了此 Bug。
最后我选择了英国 AI 安全研究所和 Meridian Labs 开发的 Inspect(一个开源评估框架),它的效果更好,尽管其 Web UI 非常简约。
这个评测很快变得非常昂贵!为了进行大约 3 次评测尝试,我在 Token 费用上花掉了 300 多美元。以下结果来自最近的一次尝试。当模型能够解释正确的事件序列时,即授予及格分:中断设置了 errflag,从而中止了 readhistfile 并导致历史文件被截断。
AI 评估设置:故障现象 + bpftrace
完整的 Prompt,包含正常/截断的 bpftrace fwiw, my logout habit: i press ctrl+c / ctrl+d repeatedly until all my terminal windows are gone, and then see what’s left. 当我登出时,有时第二天回来发现我的 .zsh_history 文件被莫名其妙地截断了。可能是什么原因?
我在 Linux 上运行 zsh 5.9.1。只有 zsh 会写入这个文件。我有一个 bpftrace 程序记录了 zsh 针对历史文件所做的每一个系统调用。
正常的登出看起来像这样:
zsh(231222) symlink /pid-231222/host-midna -> /home/michael/.zsh_history.LOCK zsh(231222) openat: /home/michael/.zsh_history flags 541 mode 180 zsh(231222) close 3 (reads: 0, writes: 0) zsh(231222) openat: /home/michael/.zsh_history flags 0 mode 0 zsh(231222) lseek fd 3 offset 0 whence 1 zsh(231222) read = 0 zsh(231222) close 3 (reads: 52895744, writes: 0) zsh(231222) unlink /home/michael/.zsh_history.new zsh(231222) openat: /home/michael/.zsh_history.new flags c1 mode 180 zsh(231222) close 3 (reads: 0, writes: 52888907) zsh(231222) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history zsh(231222) unlink /home/michael/.zsh_history.LOCK截断了文件的登出看起来像这样:
zsh(231233) symlink /pid-231233/host-midna -> /home/michael/.zsh_history.LOCK zsh(231233) openat: /home/michael/.zsh_history flags 541 mode 180 zsh(231233) close 3 (reads: 0, writes: 0) zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0 zsh(231233) lseek fd 3 offset 0 whence 1 zsh(231233) lseek fd 3 offset 0 whence 1 zsh(231233) lseek fd 3 offset 11572944 whence 0 zsh(231233) close 3 (reads: 11575296, writes: 0) zsh(231233) unlink /home/michael/.zsh_history.new zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180 zsh(231233) close 3 (reads: 0, writes: 11572944) zsh(231233) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history zsh(231233) unlink /home/michael/.zsh_history.LOCK我的 zshrc 在 ./zshrc 中——这是受影响机器上生效的精确配置,因此你可以看到启用了(和未启用)哪些选项。
完整的 zsh 5.9.1 源码树位于 ./zsh-5.9.1 中——这正是我运行的版本。请根据需要深入钻研。
发生了什么?zsh 源码中的什么原因导致的?
请仅从提供的 zsh 5.9.1 源码和上述证据出发进行工作。请勿查阅更新的 zsh 版本、上游提交、邮件列表线程、变更日志或发行说明——重点是从此源码中推导出原因,而不是去查找它是如何被后来修复的。
请以标题严格为
## Diagnosis的小节结束你的回复,其中包含你的最终答案:根本原因以及负责的具体代码。
| 得分 | 模型 | Token 消耗 | 耗时 |
|---|---|---|---|
| ✅ 3 / 3 | openai/gpt-5.6-sol | 497,040 | 2m 29s |
| ✅ 3 / 3 | anthropic/claude-opus-5 | 5,440,676 | 26m 43s |
| ⚠️ 2 / 3 | openai/gpt-5.5 | 659,050 | 2m 43s |
| ⚠️ 1 / 3 | openai/gpt-5.6-terra | 673,336 | 1m 57s |
| ⚠️ 1 / 3 | google/gemini-3.1-pro-preview | 3,733,226 | 9m 31s |
| ⚠️ 1 / 3 | anthropic/claude-sonnet-5 | 9,323,989 | 29m 18s |
| ⚠️ 1 / 3 | google/gemini-3.5-flash | 6,746,305 | 12m 38s |
| ⚠️ 1 / 3 | moonshotai/kimi-k3 (开源权重!) @ medium | 2,455,874 | 45m 6s |
| ⚠️ 1 / 3 | moonshotai/kimi-k3 (开源权重!) @ high | 13,060,937 | 52m 2s |
| ⚠️ 1 / 3 | google/gemini-3-flash-preview | 19,370,711 | 30m 14s |
| ❌ | openai/gpt-5.1 | 118,948 | 1m 12s |
| ❌ | openai/gpt-5.4 | 286,288 | 1m 10s |
| ❌ | openai/gpt-5.6-luna | 549,724 | 1m 14s |
| ❌ | qwen/qwen3-coder | 623,110 | 5m 2s |
| ❌ | openai/gpt-5.2 | 1,306,586 | 1m 49s |
| ❌ | openai/gpt-5 | 2,858,548 | 7m 14s |
| ❌ | anthropic/claude-opus-4-8 | 3,402,954 | 9m 5s |
| ❌ | deepseek/deepseek-v4-flash-0731 (开源权重!) | 5,570,798 | 25m 6s |
| ❌ | google/gemini-3.1-flash-lite | 6,945,359 | 2m 7s |
| ❌ | qwen/qwen3.8-max (开源权重!) | 6,307,959 | 39m 32s |
| ❌ | deepseek/deepseek-v4-pro (开源权重!) | 8,577,105 | 30m 7s |
| ❌ | anthropic/claude-haiku-4-5 | 10,646,235 | 7m 24s |
| ❌ | qwen/qwen3.6-max-preview | 19,689,360 | 27m 12s |
| ❌ | minimax/minimax-m3 (开源权重!) | 19,937,971 | 46m 28s |
| ❌ | z-ai/glm-5.2 (开源权重!) | 21,507,452 | 29m 30s |
评测变体:habit-hinted(带习惯提示)
在此次迭代中,我加入了关于重复按 Ctrl+C 和 Ctrl+D 的提示,这是一个指向信号和中断处理的微小线索:
顺便说一下我的登出习惯:我不断地按下 ctrl+c / ctrl+d,直到我所有的终端窗口都消失,然后看看剩下什么。
这用来衡量模型是否能轻松理解该问题。
| 得分 | 模型 | Token 消耗 | 耗时 |
|---|---|---|---|
| ✅ 3 / 3 | openai/gpt-5.6-sol | 393,895 | 1m 43s |
| ✅ 3 / 3 | openai/gpt-5.5 | 622,081 | 1m 31s |
| ✅ 3 / 3 | anthropic/claude-opus-5 | 1,583,601 | 7m 29s |
| ✅ 3 / 3 | anthropic/claude-opus-4-8 | 2,338,905 | 6m 4s |
| ✅ 3 / 3 | anthropic/claude-sonnet-5 | 3,132,322 | 14m 9s |
| ✅ 3 / 3 | moonshotai/kimi-k3 @ medium (开源权重!) | 4,642,764 | 32m 25s |
| ✅ 3 / 3 | moonshotai/kimi-k3 @ high (开源权重!) | 9,631,629 | 32m 17s |
| ✅ 3 / 3 | z-ai/glm-5.2 (开源权重!) | 23,654,094 | 22m 37s |
| ⚠️ 2 / 3 | openai/gpt-5 | 2,345,526 | 3m 38s |
| ⚠️ 2 / 3 | google/gemini-3-flash-preview | 6,421,624 | 16m 41s |
| ⚠️ 2 / 3 | google/gemini-3.5-flash | 3,571,679 | 9m 7s |
| ⚠️ 2 / 3 | qwen/qwen3.8-max (开源权重!) | 4,289,195 | 41m 49s |
| ⚠️ 1 / 3 | openai/gpt-5.6-luna | 426,974 | 1m 26s |
| ⚠️ 1 / 3 | openai/gpt-5.6-terra | 728,604 | 1m 31s |
| ⚠️ 1 / 3 | google/gemini-3.1-pro-preview | 3,525,585 | 6m 23s |
| ⚠️ 1 / 3 | deepseek/deepseek-v4-flash-0731 (开源权重!) | 3,628,997 | 25m 37s |
| ⚠️ 1 / 3 | deepseek/deepseek-v4-pro (开源权重!) | 7,751,190 | 30m 4s |
| ❌ | openai/gpt-5.4 | 266,719 | 45s |
| ❌ | openai/gpt-5.1 | 287,721 | 1m 9s |
| ❌ | openai/gpt-5.2 | 1,097,940 | 1m 30s |
| ❌ | qwen/qwen3-coder (开源权重!) | 1,160,681 | 8m 31s |
| ❌ | anthropic/claude-haiku-4-5 | 5,663,995 | 5m 47s |
| ❌ | google/gemini-3.1-flash-lite | 5,839,696 | 1m 39s |
| ❌ | minimax/minimax-m3 (开源权重!) | 10,733,126 | 27m 25s |
| ❌ | qwen/qwen3.6-max-preview | 13,322,764 | 30m 4s |
AI 评估结论
诸如 Claude Opus 5 或 GPT 5.6 Sol 等最新的尖端模型,仅凭故障现象的描述以及正常/失效的 bpftrace 就可以可靠地找到这个 Bug。多尝试几次,Gemini 模型也能做到这一点。在开源权重模型中,只有 Kimi K3 能够在没有提示的情况下找到这个 Bug。
一旦在 Prompt 中包含了 Ctrl+C + Ctrl+D 的习惯,更多的尖端模型就能可靠地找出问题(包括 Claude Sonnet 5!)。在开源权重模型中,GLM 5.2 和 Kimi K3 是最先能够可靠理清该问题的模型!多尝试几次,Gemini 或 DeepSeek 模型也能做到这一点。我没能让 Qwen 或 Minimax 模型通过测试。
这似乎是一个非常棒的评测,特别是对于跟踪哪个开源权重模型实际上能像 Opus 或 GPT 一样出色(至少在这个特定方面)。目前来看,Kimi K3 似乎是最具能力的开源权重模型,尽管它不能完全可靠地诊断出这个问题。GLM 5.2 的体积要小得多,而且在给出提示的情况下,至少能理清问题的脉络。
有趣的是,几乎所有模型都考虑过正确的假设,包括 Qwen 和 Minimax 模型。只有 Gemini 3.1 Flash Lite 从未阐明过正确的假设,这大概是因为它是一个较小的模型(相对而言)。
那么模型们在哪里翻车了呢?在验证/证伪理论的过程中!例如,GLM 5.2 假定 bpftrace 输出中的 lseek 一定意味着设置了 SHAREHISTORY(实际上并没有!):
glm-5.2 列举了导致短读取的恰好三种原因——损坏、
HFILE_FAST搜索、errflag & ERRFLAG_INT——随后排除了中断,因为“选项 1 和 3 不涉及对非零偏移量的 lseek。但轨迹显示了lseek(offset, SEEK_SET),这是HFILE_FAST的行为。所以SHAREHISTORY一定被设置了”——从而覆盖了你zshrc中的unsetopt SHARE_HISTORY以使该排除项成立。
我通过让评测使用更多编排(让一个子智能体产生理论,另一个智能体负责跟踪并证伪/验证等)进行了验证,发现成功率确实提高了。同样,我预计通过调整 Prompt 和框架,可以使单个模型的表现变得好得多。
最常见的失败模式似乎是模型选择了错误的理论并执着于验证它,而没有回到其他理论。也许表现更好的模型拥有更好的方法论,因为它们更严格地遵循了科学方法?